查看原文
其他

Cocos Creator 通用框架设计——资源管理

COCOS 2022-06-10


Cocos


编者按


本文来自 Cocos 中文社区,作者宝爷。


资/源/管/理



如果你想使用 Cocos Creator 制作一些规模稍大的游戏,那么资源管理是必须解决的问题。


随着游戏的进行,你可能会发现游戏的内存占用只升不降,哪怕你当前只用到了极少的资源,并且有使用 cc.loader.release 来释放之前加载的资源。


但之前使用过的大部分资源都会留在内存中!


为什么会这样呢?


 Cocos Creator 资源管理存在的问题


资源管理主要解决 3 个问题,资源加载,资源查找(使用),资源释放


这里要讨论的主要是资源释放的问题,这个问题看上去非常简单,在 Cocos2d-x 中确实也很简单,但在 js 中变得复杂了起来,因为难以跟踪一个资源是否可以被释放。


在 Cocos2d-x 中我们使用引用计数,在引用计数为 0 的时候释放资源,维护好引用计数即可,而且在 Cocos2d-x 中我们对资源的管理是比较分散的,引擎层面只提供如 TextureCache 、 AudioManager 之类的单例来管理某种特定的资源,大多数的资源都需要我们自己去管理。


而在 Cocos Creator 中,我们的资源统一由 cc.loader 来管理,大量使用 prefab , prefab 与各种资源复杂的引用关系增加了资源管理的难度。



01


资源依赖


资源 A 可能依赖资源 B 、 C 、 D ,而资源 D 又依赖资源 E ,这是非常常见的一种资源依赖情况。

如果我们使用  cc.loader.loadRes("A")  加载资源 A , B ~ E 都会被加载进来,但如果我们调用  cc.loader.release("A")  则只有资源 A 被释放。

每一个加载的资源都会放到 cc.loader的_cache 中,但 cc.loader.release 只是将传入的资源进行释放,而没有考虑资源依赖的情况。


如果我们希望将依赖的资源也一起释放, Cocos Creator 提供了一个方法, cc.loader.getDependsRecursively;  ,递归获取指定资源依赖的所有资源,放入一个数组并返回,然后在 cc.loader.release 中传入该数组, cc.loader  会遍历它们,将其逐个释放。


这种方式虽然可以将资源释放,但却有可能释放了不应该释放的资源。


如果有一个资源 F 依赖 D ,这时候就会导致 F 资源无法正常工作。由于 Cocos Creator 引擎没有维护好资源的依赖,导致我们在释放 D 的时候并不知道还有 F 依赖我们。


即使没有 F 依赖,我们也不确定是否可以释放 D ,比如我们调用 cc.loader 加载 D ,而后又加载了 A ,此时 D 已经加载完成, A 可以直接使用。


但如果释放 A 的时候,将 D 也释放了,这就不符合我们的预期。


我们期望的是在我们没有显式地释放 D 时, D 不应该随着其它资源的释放而自动释放。



可以简单地进行测试:
打开 Chrome 的开发者模式,在 Console 面板中进行输入,如果是旧版本的 Cocos Creator ,可以在 cc.textureCache 中 dump 所有的纹理。
而新版本移除了 textureCache ,但我们可以输入 cc.loader.cache 来查看所有的资源。
如果资源太多,只关心数量,可以输入 Object.keys(cc.loader.cache).length 来查看资源总数。我们可以在资源加载前 dump 一次,加载后 dump 一次,释放后再 dump 一次,来对比 cc.loader 中的缓存状态。
当然,也可以写一些便捷的方法,如只 dump 图片,或者 dump 与上次 的差异项。





02


资源使用


除了资源依赖的问题,我们还需要解决资源使用的问题。前者是 cc.loader 内部的资源组织问题,后者是应用层逻辑的资源使用问题。


比如我们需要在一个界面关闭的时候释放某资源,同样会面临一个该不该释放的问题,比如另外一个未关闭的界面是否使用了该资源?


如果有其他地方用到了该资源,那么就不应该释放它!


03


ResLoader


在这里我设计了一个 ResLoader ,来解决 cc.loader 没有解决好的问题。


关键是为每一个资源创建一个 CacheInfo 来记录资源的依赖和使用等信息,以此来判断资源是否可以释放。


使用 ResLoader.getInstance().loadRes() 来替代 cc.loader.loadRes(),ResLoader.getInstance().releaseRes() 来替代 cc.loader.releaseRes() 。对于依赖,在资源加载的时候 ResLoader 会自动建立起映射,释放资源的时候会自动取消映射,并检测取消映射后的资源是否可以释放,是才走释放的逻辑。


对于使用,提供了一个 use 参数,通过该参数来区别是哪里使用了该资源,以及是否有其他地方使用了该资源。


当一个资源即没有被其他资源依赖,也没有被其它逻辑使用,那么这个资源就可以被释放。


/** * 资源加载类 * 1. 加载完成后自动记录引用关系,根据DependKeys记录反向依赖 * 2. 支持资源使用,如某打开的UI使用了A资源,其他地方释放资源B,资源B引用了资源A,如果没有其他引用资源A的资源,会触发资源A的释放, * 3. 能够安全释放依赖资源(一个资源同时被多个资源引用,只有当其他资源都释放时,该资源才会被释放) * * 2018-7-17 by 宝爷 */
// 资源加载的处理回调export type ProcessCallback = (completedCount: number, totalCount: number, item: any) => void;// 资源加载的完成回调export type CompletedCallback = (error: Error, resource: any) => void;
// 引用和使用的结构体interface CacheInfo { refs: Set<string>, uses: Set<string>}
// LoadRes方法的参数结构interface LoadResArgs { url: string, type?: typeof cc.Asset, onCompleted?: CompletedCallback, onProgess?: ProcessCallback, use?: string,}
// ReleaseRes方法的参数结构interface ReleaseResArgs { url: string, type?: typeof cc.Asset, use?: string,}
// 兼容性处理let isChildClassOf = cc.js["isChildClassOf"]if (!isChildClassOf) { isChildClassOf = cc["isChildClassOf"];}
export default class ResLoader {
private _resMap: Map<string, CacheInfo> = new Map<string, CacheInfo>(); private static _resLoader: ResLoader = null; public static getInstance(): ResLoader { if (!this._resLoader) { this._resLoader = new ResLoader(); } return this._resLoader; }
public static destroy(): void { if (this._resLoader) { this._resLoader = null; } }
private constructor() {
}
/** * 从cc.loader中获取一个资源的item * @param url 查询的url * @param type 查询的资源类型 */ private _getResItem(url: string, type: typeof cc.Asset): any { let ccloader: any = cc.loader; let item = ccloader._cache[url]; if (!item) { let uuid = ccloader._getResUuid(url, type, false); if (uuid) { let ref = ccloader._getReferenceKey(uuid); item = ccloader._cache[ref]; } } return item; }
/** * loadRes方法的参数预处理 */ private _makeLoadResArgs(): LoadResArgs { if (arguments.length < 1 || typeof arguments[0] != "string") { console.error(`_makeLoadResArgs error ${arguments}`); return null; } let ret: LoadResArgs = { url: arguments[0] }; for (let i = 1; i < arguments.length; ++i) { if (i == 1 && isChildClassOf(arguments[i], cc.RawAsset)) { // 判断是不是第一个参数type ret.type = arguments[i]; } else if (i == arguments.length - 1 && typeof arguments[i] == "string") { // 判断是不是最后一个参数use ret.use = arguments[i]; } else if (typeof arguments[i] == "function") { // 其他情况为函数 if (arguments.length > i + 1 && typeof arguments[i + 1] == "function") { ret.onProgess = arguments[i]; } else { ret.onCompleted = arguments[i]; } } } return ret; }
/** * releaseRes方法的参数预处理 */ private _makeReleaseResArgs(): ReleaseResArgs { if (arguments.length < 1 || typeof arguments[0] != "string") { console.error(`_makeReleaseResArgs error ${arguments}`); return null; } let ret: ReleaseResArgs = { url: arguments[0] }; for (let i = 1; i < arguments.length; ++i) { if (typeof arguments[i] == "string") { ret.use = arguments[i]; } else { ret.type = arguments[i]; } } return ret; }
/** * 生成一个资源使用Key * @param where 在哪里使用,如Scene、UI、Pool * @param who 使用者,如Login、UIHelp... * @param why 使用原因,自定义... */ public static makeUseKey(where: string, who: string = "none", why: string = ""): string { return `use_${where}_by_${who}_for_${why}`; }
/** * 获取资源缓存信息 * @param key 要获取的资源url */ public getCacheInfo(key: string): CacheInfo { if (!this._resMap.has(key)) { this._resMap.set(key, { refs: new Set<string>(), uses: new Set<string>() }); } return this._resMap.get(key); }
/** * 开始加载资源 * @param url 资源url * @param type 资源类型,默认为null * @param onProgess 加载进度回调 * @param onCompleted 加载完成回调 * @param use 资源使用key,根据makeUseKey方法生成 */ public loadRes(url: string, use?: string); public loadRes(url: string, onCompleted: CompletedCallback, use?: string); public loadRes(url: string, onProgess: ProcessCallback, onCompleted: CompletedCallback, use?: string); public loadRes(url: string, type: typeof cc.Asset, use?: string); public loadRes(url: string, type: typeof cc.Asset, onCompleted: CompletedCallback, use?: string); public loadRes(url: string, type: typeof cc.Asset, onProgess: ProcessCallback, onCompleted: CompletedCallback, use?: string); public loadRes() { let resArgs: LoadResArgs = this._makeLoadResArgs.apply(this, arguments); console.time("loadRes|"+resArgs.url); let finishCallback = (error: Error, resource: any) => { // 反向关联引用(为所有引用到的资源打上本资源引用到的标记) let addDependKey = (item, refKey) => { if (item && item.dependKeys && Array.isArray(item.dependKeys)) { for (let depKey of item.dependKeys) { // 记录该资源被我引用 this.getCacheInfo(depKey).refs.add(refKey); // cc.log(`${depKey} ref by ${refKey}`); let ccloader: any = cc.loader; let depItem = ccloader._cache[depKey] addDependKey(depItem, refKey) } } }
let item = this._getResItem(resArgs.url, resArgs.type); if (item && item.url) { addDependKey(item, item.url); } else { cc.warn(`addDependKey item error1! for ${resArgs.url}`); }
// 给自己加一个自身的引用 if (item) { let info = this.getCacheInfo(item.url); info.refs.add(item.url); // 更新资源使用 if (resArgs.use) { info.uses.add(resArgs.use); } }
// 执行完成回调 if (resArgs.onCompleted) { resArgs.onCompleted(error, resource); } console.timeEnd("loadRes|"+resArgs.url); };
// 预判是否资源已加载 let res = cc.loader.getRes(resArgs.url, resArgs.type); if (res) { finishCallback(null, res); } else { cc.loader.loadRes(resArgs.url, resArgs.type, resArgs.onProgess, finishCallback); } }
/** * 释放资源 * @param url 要释放的url * @param type 资源类型 * @param use 要解除的资源使用key,根据makeUseKey方法生成 */ public releaseRes(url: string, use?: string); public releaseRes(url: string, type: typeof cc.Asset, use?: string) public releaseRes() { /**暂时不释放资源 */ // return;
let resArgs: ReleaseResArgs = this._makeReleaseResArgs.apply(this, arguments); let item = this._getResItem(resArgs.url, resArgs.type); if (!item) { console.warn(`releaseRes item is null ${resArgs.url} ${resArgs.type}`); return; } cc.log("resloader release item"); // cc.log(arguments); let cacheInfo = this.getCacheInfo(item.url); if (resArgs.use) { cacheInfo.uses.delete(resArgs.use) } this._release(item, item.url); }
// 释放一个资源 private _release(item, itemUrl) { if (!item) { return; } let cacheInfo = this.getCacheInfo(item.url); // 解除自身对自己的引用 cacheInfo.refs.delete(itemUrl);
if (cacheInfo.uses.size == 0 && cacheInfo.refs.size == 0) { // 解除引用 let delDependKey = (item, refKey) => { if (item && item.dependKeys && Array.isArray(item.dependKeys)) { for (let depKey of item.dependKeys) { let ccloader: any = cc.loader; let depItem = ccloader._cache[depKey] this._release(depItem, refKey); } } } delDependKey(item, itemUrl); //如果没有uuid,就直接释放url if (item.uuid) { cc.loader.release(item.uuid); cc.log("resloader release item by uuid :" + item.url); } else { cc.loader.release(item.url); cc.log("resloader release item by url:" + item.url); } } }
/** * 判断一个资源能否被释放 * @param url 资源url * @param type 资源类型 * @param use 要解除的资源使用key,根据makeUseKey方法生成 */ public checkReleaseUse(url: string, use?: string): boolean; public checkReleaseUse(url: string, type: typeof cc.Asset, use?: string): boolean public checkReleaseUse() { let resArgs: ReleaseResArgs = this._makeReleaseResArgs.apply(this, arguments); let item = this._getResItem(resArgs.url, resArgs.type); if (!item) { console.log(`cant release,item is null ${resArgs.url} ${resArgs.type}`); return true; }
let cacheInfo = this.getCacheInfo(item.url); let checkUse = false; let checkRef = false;
if (resArgs.use && cacheInfo.uses.size > 0) { if (cacheInfo.uses.size == 1 && cacheInfo.uses.has(resArgs.use)) { checkUse = true; } else { checkUse = false; } } else { checkUse = true; }
if ((cacheInfo.refs.size == 1 && cacheInfo.refs.has(item.url)) || cacheInfo.refs.size == 0) { checkRef = true; } else { checkRef = false; }
return checkUse && checkRef; }}


04


使用ResLoader


ResLoader 的使用非常简单,下面是一个简单的例子。


我们可以点击 dump 按钮来查看当前的资源总数,点击 cc.load、cc.release 之后分别 dump 一次。


可以发现,开始有 36 个资源,加载之后有 40 个资源,而执行释放之后,还有 39 个资源,只释放了一个资源。如果使用 ResLoader 进行测试,发现释放之后只有 34 个资源。


这是因为前面加载场景的资源也被该测试资源依赖,所以这些资源也被释放掉了。只要我们都使用 ResLoader 来加载和卸载资源,就不会出现资源泄露的问题。



示例代码:

@ccclassexport default class NetExample extends cc.Component { @property(cc.Node) attachNode: cc.Node = null; @property(cc.Label) dumpLabel: cc.Label = null;
onLoadRes() { cc.loader.loadRes("Prefab/HelloWorld", cc.Prefab, (error: Error, prefab: cc.Prefab) => { if (!error) { cc.instantiate(prefab).parent = this.attachNode; } }); }
onUnloadRes() { this.attachNode.removeAllChildren(true); cc.loader.releaseRes("Prefab/HelloWorld"); }
onMyLoadRes() { ResLoader.getInstance().loadRes("Prefab/HelloWorld", cc.Prefab, (error: Error, prefab: cc.Prefab) => { if (!error) { cc.instantiate(prefab).parent = this.attachNode; } }); }
onMyUnloadRes() { this.attachNode.removeAllChildren(true); ResLoader.getInstance().releaseRes("Prefab/HelloWorld"); }
onDump() { let Loader:any = cc.loader; this.dumpLabel.string = `当前资源总数:${Object.keys(Loader._cache).length}`; }}


可以看到上面的例子是先移除节点,再进行释放,这是正确的使用方式。如果我没有移除直接释放呢?


因为释放了纹理,所以 Cocos Creator 在接下来的渲染中会不断报错。


ResLoader 只是一个基础。直接使用 ResLoader 我们不需要关心资源的依赖问题,但资源的使用问题我们还需要关心。


在实际的使用中,我们可能希望资源的生命周期是以下几种情况:

  1. 跟随某对象的生命周期,对象销毁时资源释放

  2. 跟随某界面的生命周期,界面关闭时资源释放

  3. 跟随某场景的生命周期,场景切换时资源释放


我们可以实现一个组件挂在到对象身上,当我们在该对象或该对象的其它组件中编写逻辑,加载资源时,使用这个资源管理组件进行加载,由该组件来维护资源的释放。


界面和场景也类似。项目代码位于:https://github.com/wyb10a10/cocos_creator_framework  ,打开 Scene 目录的 ResExample 场景即可查看。


 


以上就是本次的分享,如有问题或新的想法欢迎留言嗷~
如果您在使用 Cocos Creator 2D/3D 的过程中 get 了独到的开发心得、见解或是方法欢迎分享出来帮助更多开发者们解决技术问题让游戏开发更简单~
期待您与我们联系~



更多精彩

年创数十款新游的大佬竟呼吁:想赚钱就不要做游戏?

Cocos Creator v2.2.2 正式发布

来啊,斗图啊!Cocos 官方定制表情包来啦~

Creator 3D 材质系统:曲面效果如何实现?

最贵的游戏机来了!特斯拉在线发牌

点击下方“阅读原文”前往社区参与讨论~


您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存